#!/usr/bin/env python
# -*- coding: utf-8 -*-
'''clitwi.py: simple command line twitter client in python
usage: [python] clitwi.py

you have to create .clitwi file in your home directory:

user = your twitter username
password = your twitter pasword
#interval = update interval in minutes

user and password are required!
'''

import sys
import threading
import twitter
import readline
import re

DefaultInterval = 5

Tags = {                        # Syntax hilighting:
    'prompt': 'red',            # program prompt
    'error':  'brightred',      # error messages
    'message': 'magenta',       # otherr messages
    'nr': 'brightyellow',       # tweet number
    'sender': 'green',          # tweet sender
    'time': 'cyan',             # tweet time
    'tweet': 'off',             # text of the tweet itself
    'dm': 'brightred',          # DM number
    'mention': 'yellow',        # mentions (replies to you)
    'inbox': 'red',             # direct messages
    'help_title': 'brightyellow',
    'help_key': 'yellow',
    'help_command': 'blue',
    }

Regexps = {
    '@\w+': 'brightblue',       # user names
    '#\w+': 'magenta',          # hashtags
    }


class Colorer:
    '''Format strings with ANSI colors using special format tags
    '''
    colormap = {
        'off':           '\x1b[0m',
        'red' :          '\x1b[0;31m',
        'green':         '\x1b[0;32m',
        'yellow':        '\x1b[0;33m',
        'blue':          '\x1b[0;34m',
        'magenta':       '\x1b[0;35m',
        'cyan':          '\x1b[0;36m',
        'grey':          '\x1b[0;37m',
        'brightred' :    '\x1b[31;1m',
        'brightgreen':   '\x1b[32;1m',
        'brightyellow':  '\x1b[33;1m',
        'brightblue':    '\x1b[34;1m',
        'brightmagenta': '\x1b[35;1m',
        'brightcyan':    '\x1b[36;1m',
        'white':         '\x1b[37;1m'
        }
    def __init__(self, ansi=True):
        self.tags = dict()
        self.ansi = ansi

    def setTags(self, tags=dict(), regexps=dict()):
        self.tags = tags.copy()
        self.regexps = regexps.copy()

    def setAnsi(self, ansi):
        self.ansi = ansi

    def format(self, text, baseColor=None):
        import re
        if self.ansi:
            colorOff = self.colormap['off']
        else:
            colorOff = ''
        if baseColor is None:
            baseColor = colorOff
        elif self.ansi:
            baseColor = self.colormap[baseColor]
        else:
            baseColor = ''
        result = text
        for tag, color in self.tags.items():
            if self.ansi:
                colorOn = self.colormap[color]
            else:
                colorOn = ''
            result = re.sub('<%s>(.+)</%s>' % (tag, tag),
                            '%s\\1%s' % (colorOn, baseColor),
                            result)
        for regexp, color in self.regexps.items():
            if self.ansi:
                colorOn = self.colormap[color]
            else:
                colorOn = ''
            result = re.sub('(%s)' % regexp,
                            '%s\\1%s' %  (colorOn, baseColor),
                            result)
        return result + colorOff

colorer = Colorer()
    
colorer.setTags(Tags, Regexps)
                
Prompt = 'TWI> '

def error(msg):
    print colorer.format('\n<error>ERROR: %s</error>' % (msg))

def message(msg):
    print colorer.format('\n<message>**** %s</message>' % (msg))

def NumberGenerator(one=ord('a'), two=0):
    '''Generate tweet numbers as LD where L - letter (a-z), D - digit (0-9)
    '''
    while True:
        yield '%s%s' % (chr(one), str(two))
        two = (two + 1) % 10
        if two == 0:
            one = (one + 1)
            if one > ord('z'):
                one = ord('a')

def unescape(s):
    from htmlentitydefs import entitydefs
    def repl(m):
        try:
            return entitydefs[m.group(1)]
        except KeyError:
            return m.group(1)
    return re.sub('&(.+?);', repl, s)

class Updater(threading.Thread):
    '''Asinchrounous update timelines
    callback must be a callable object
    interval - in minutes
    '''
    def __init__(self, callback, interval=10):
        super(Updater, self).__init__()
        self.setInterval(interval)
        self.callback = callback
        self.busy = threading.Lock()
        self.stopEvent = threading.Event()
        self.updated = threading.Event()
        self.count = 0
        self.countLock = threading.Lock()

    def setInterval(self, interval):
        self.interval = interval

    def stop(self):
        self.stopEvent.set()
        while self.isAlive():
            pass

    def sleep(self):
        import time
        self.countLock.acquire()
        self.count = self.interval * 12 # 5 sec for each sleep
        while self.count > 0 and not self.stopEvent.isSet():
            self.countLock.release()
            time.sleep(5)
            self.countLock.acquire()
            self.count -= 1
        self.countLock.release()

    def refresh(self):
        '''Immediately update the timeline
        Actually, resets count in sleep
        '''
        self.countLock.acquire()
        self.count = 0
        self.updated.clear()
        self.countLock.release()

    def run(self):
        while not self.stopEvent.isSet():
            self.busy.acquire()
            self.callback()
            self.busy.release()
            self.updated.set()
            self.sleep()
        message('Updater finished')

class StatusList:
    '''List of statuses (timeline)
    '''
    def __init__(self, username, colorer, statuses=list()):
        self.username = username
        self.enum = NumberGenerator()
        self.map = dict() # status id -> internal nr
        self.statuses = dict() # {internal nr: status}
        self.colorer = colorer
        self.update(statuses)

    def isDM(self, nr):
        return self._isDMs(self.statuses[nr])

    def _isDMs(self, status):
        return type(status) == twitter.DirectMessage

    def getStatusIDAndUser(self, nr):
        try:
            status = self.statuses[nr]
            if self.isDM(nr):
                return (status.GetId(), status.GetSenderScreenName().encode('utf-8'))
            else:
                return (status.GetId(), status.GetUser().GetScreenName().encode('utf-8'))
        except:
            return None

    def getStatus(self, nr):
        return self.statuses[nr]

    def _remap(self):
        '''Reduce number of tweets to max(NumberGenerator)
        '''
        newmap = dict()
        for snr, status in self.statuses.items():
            newmap[status.GetId()] = snr
        self.map = newmap

    def update(self, statuses):
        ids = self.map.keys()
        for status in statuses:
            isnew = not self.map.has_key(status.GetId())
            snr = self.append(status)
            if isnew:
                self.printStatusOrDM(snr)
        self._remap()

    def append(self, status):
        sid = status.GetId()
        if self.map.has_key(sid):
            snr = self.map[sid]
        else:
            snr = self.enum.next()
        self.statuses[snr] = status
        self.map[sid] = snr
        return snr

    def delete(self, snr):
        status = self.getStatus(snr)
        del self.statuses[snr]
        self._remap()
        return status

    def _getStatusText(self, status):
        return status.GetText().encode('utf-8').replace('\n', ' ')

    def printStatus(self, nr, status=None):
        if status is None:
            status = self.statuses[nr]
        txt = self._getStatusText(status)
        if re.search('@%s\\b' % self.username, txt):
            baseColor = Tags['mention']
        else:
            baseColor = None
        ftxt = self.colorer.format('<nr>%s</nr> <sender>%s</sender>: %s <time>(%s)</time>\n' % (nr, status.GetUser().GetScreenName().encode('utf-8'), txt, status.GetRelativeCreatedAt().encode('utf-8')), baseColor)
        sys.stdout.write(unescape(ftxt))
        sys.stdout.flush()

    def printDM(self, nr, status=None):
        if status is None:
            status = self.statuses[nr]
        txt = self._getStatusText(status)
        ftxt = self.colorer.format('<dm>%s</dm> <sender>%s</sender>: %s <time>(%s)</time>\n' % (nr, status.GetSenderScreenName().encode('utf-8'), txt, status.GetCreatedAt().encode('utf-8')), Tags['inbox'])
        sys.stdout.write(unescape(ftxt))
        sys.stdout.flush()

    def printStatusOrDM(self, nr):
        if self.isDM(nr):
            self.printDM(nr)
        else:
            self.printStatus(nr)

    def printList(self):
        keys = self.statuses.keys()
        keys.sort()
        for nr in keys:
            self.printStatusOrDM(nr)

    def getMaxId(self):
        '''Return the maximum status ID
        '''
        try:
            return max(self.map.keys())
        except ValueError:
            return None

class TwiCLI:
    '''Command line client implementation
    '''
    def __init__(self, user, passwd, interval, limit, colorer):
        self.user = user
        self.passwd = passwd
        self.limit = limit
        self.colorer = colorer
        self.api = twitter.Api(username=user, password=passwd)
        self.commands = (
            # (command, callback, doc)
            ('!help', self.printHelp),
            ('!refresh', self.refreshTimeline),
            ('!again', self.printTimeline),
            ('!home', self.printFriendsTimeline),
            ('!replies', self.printReplies),
            ('!inbox', self.printInbox),
            ('!re', self.reply),
            ('!rt', self.retweet),
            ('!dm', self.dm),
            ('!delete', self.deleteStatus),
            ('!bye', self.quit),
            )
        self.updater = Updater(self.loadTimeline, interval)
        self.switchToFriendsTimeline()
        self.inbox = StatusList(self.user, self.colorer)
        self.inInbox = False

    def loadTimeline(self):
        '''Callback method for Updater'''
        if self.inInbox:
            timeline = self.inbox
        else:
            timeline = self.timeline
        try:
            statuses = self.updateMethod(timeline.getMaxId())
        except Exception, details:
            error(str(details))
            return
        statuses.reverse()
        timeline.update(statuses)

    def refreshTimeline(self, dummy=''):
        'Refresh your current timeline'
        self.updater.refresh()
        self.updater.updated.wait()
        self.printTimeline()

    def printTimeline(self, dummy=''):
        'Redisplay the same timeline (no new updates downloaded)'
        self.updater.busy.acquire()
        if self.inInbox:
            self.inbox.printList()
        else:
            self.timeline.printList()
        self.updater.busy.release()

    def switchToFriendsTimeline(self):
        self.inInbox = False
        self.timeline = StatusList(self.user, self.colorer)
        self.updateMethod = lambda x: self.api.GetFriendsTimeline(self.user, count=self.limit, since_id=x)

    def switchToReplies(self):
        self.inInbox = False
        self.timeline = StatusList(self.user, self.colorer)
        self.updateMethod = lambda x: self.api.GetReplies(since_id=x)

    def switchToInbox(self):
        self.inInbox = True
        self.updateMethod = lambda x: self.api.GetDirectMessages(since_id=x)

    def printFriendsTimeline(self, dummy=''):
        'Switch current time line to ypur home'
        self.switchToFriendsTimeline()
        self.refreshTimeline()
#         self.printTimeline()

    def printReplies(self, dummy=''):
        'Switch current timeline to replies'
        self.switchToReplies()
        self.refreshTimeline()
        self.printTimeline()

    def printInbox(self, dummy=''):
        'Switch to Inbox (direct messages)'
        self.switchToInbox()
        message('Going to Inbox')
        self.refreshTimeline()

    def update(self, rawstatus, in_reply_to=None):
        'Post a tweet'
        rawstatus = rawstatus.decode('utf-8')
        if len(rawstatus) > twitter.CHARACTER_LIMIT:
            error('Text must be less than or equal to %d characters!' % (twitter.CHARACTER_LIMIT))
            return
        try:
            status = self.api.PostUpdate(rawstatus, in_reply_to)
        except Exception, details:
            error(str(details))
            return
        nr = self.timeline.append(status)
        self.timeline.printStatusOrDM(nr)

    def sendDM(self, username, rawstatus):
        'Send DM to user'
        rawstatus = rawstatus.decode('utf-8')
        if len(rawstatus) > twitter.CHARACTER_LIMIT:
            error('Text must be less than or equal to %d characters!' % (twitter.CHARACTER_LIMIT))
            return
        username = username.decode('utf-8')
        try:
            status = self.api.PostDirectMessage(username, rawstatus)
        except Exception, details:
            error(str(details))
            return
        self.inbox.printDM('**', status)

    def reply(self, text):
        'Reply to the tweet or DM: !re NR text (sends DM in reply to DM)'
        try:
            tweetnr, text = text.split(' ', 1)
        except:
            error('Invalid reply. Use !re NR text!')
            return
        if self.inInbox:
            stid, uname = self.inbox.getStatusIDAndUser(tweetnr)
        else:
            stid, uname = self.timeline.getStatusIDAndUser(tweetnr)
        if stid is None:
            error('Wrong tweet number: %s!' % stid)
            return
        if self.inInbox:
            self.sendDM(uname, text)
        else:
            self.update('@%s %s' % (uname, text), stid)

    def retweet(self, text):
        'Retweet any tweet: !rt NR puts the text "RT @user text" to the command line'
        try:
            tweetnr, text = text.split(' ', 1)
        except:
            tweetnr = text
        if self.inInbox:
            error('Cannot retweet DM!')
            return
        status = self.timeline.getStatus(tweetnr)
        if status is None:
            error('Invalid retweet. Use !rt NR!')
            return
        rt = 'RT @%s: %s' % (status.GetUser().GetScreenName().encode('utf-8'), status.GetText().encode('utf-8').replace('\n', ' '))
        hook = lambda : readline.insert_text(rt) and readline.redisplay()
        readline.set_startup_hook(hook)

    def dm(self, text):
        'Send DM to smb. !dm user text'
        try:
            user, text = text.split(' ', 1)
        except:
            error('Invalid DM. Use !dm NR text!')
            return
        self.sendDM(user, text)

    def deleteStatus(self, text=''):
        'Delete your tweet or DM: /delete NR'
        try:
            tweetnr, text = text.split(' ', 1)
        except:
            tweetnr = text
        dd = self.timeline.getStatusIDAndUser(tweetnr)
        if dd is None:
            error('Wrong tweet number %s' % tweetnr)
            return
        (sid, user) = dd
        if user != self.user:
            error('You can destroy your own tweets and direct messages only!')
            return
        status = self.timeline.delete(tweetnr)
        if self.inInbox:
            deleted = self.api.DestroyDirectMessage(status.GetId())
        else:
            deleted = self.api.DestroyStatus(status.GetId())
        message('Tweet has been deleted')

    def quit(self, dummy=''):
        'Quit the program'
        message('Exiting...')
        self.updater.stop()
        sys.exit(0)

    def printHelp(self, dummy=''):
        'Show brief help (this one)'
        print colorer.format('''<help_title>Brief Help:</help_title>

To post tweet just type it and press <help_key>ENTER</help_key>
Or enter one of the following comands:
''')
        for cmd in self.commands:
            print colorer.format('  * <help_command>%s</help_command> - %s' % (cmd[0], cmd[1].__doc__))
        print colorer.format('Press <help_key>Ctrl-R</help_key> to refresh command line')

    def complete(self, text, state):
        '''complete function for readline
        return completions for the commands
        '''
        if state == 0:
            cmdlst = sorted(map(lambda x: x[0], self.commands))
            if text:
                self.completer_matches = [s for s in cmdlst if s and s.startswith(text) ]
            else:
                self.completer_matches = cmdlst[:]
        try:
            result = self.completer_matches[state]
        except IndexError:
            result = None
        return result

    def run(self):
        message('Fetching timeline for %s every %s min' % (self.user, self.updater.interval))
        self.updater.start()
        readline.set_completer(self.complete)
        readline.parse_and_bind('"\C-r": redraw-current-line')
        readline.parse_and_bind('"\C-i": complete')
        readline.set_completer_delims(' ')
        while True:
            try:
                text = raw_input(Prompt)
                readline.set_startup_hook()
            except KeyboardInterrupt:
                readline.set_startup_hook()
                print
                continue
            except EOFError:
                self.quit()
            if text.startswith('/'): # just hack
                text = '!' + text[1:]
            if text.startswith('!'):
                try:
                    cmd, text = text.split(' ', 1)
                except:
                    cmd = text
                    text = ''
                cmd = cmd.strip().lower()
                ok = False
                for c in self.commands:
                    if cmd == c[0]:
                        c[1](text)
                        ok = True
                        break
                if not ok:
                    print 'Unknown command:', cmd
            elif text:
                self.update(text)

def readRCFile(fname='~/.clitwi'):
    '''return {'user': username, 'password': password...} from rc-file
    '''
    from os.path import expanduser
    result = dict()
    fn = expanduser(fname)
    try:
        fd = open(fn)
    except IOError:
        return None
    for line in fd.readlines():
        line = line.strip()
        if not line: continue
        try:
            k, v = line.split('=', 1)
        except:
            print 'Wrong line in rc-file:', line
            print 'Skipped'
            continue
        result[k.strip()] = v.strip()
    return result
    
if __name__ == '__main__':
    if len(sys.argv) > 1:
        config = readRCFile(sys.argv[1])
    else:
        config = readRCFile()
    if config is None:
        print 'Config file not found!'
        print
        print __doc__
        sys.exit(1)
    for req in ('user', 'password'):
        if not config.has_key(req):
            print req, 'is not defined in config file!'
            print
            print __doc__
            sys.exit(1)
    interval = DefaultInterval
    if config.has_key('interval'):
        try:
            interval = int(config['interval'])
            if interval < 1:
                raise ValueError()
        except ValueError:
            error('Interval must be positive integer! Ignored.')
            interval = DefaultInterval
    isAnsi = True
    if config.has_key('ansi'):
        isAnsi =  config['ansi'] == 'yes'
    colorer.setAnsi(isAnsi)
    try:
        limit = int(config['limit'])
        if not (20 <= limit <= 200):
            message('Limit is out of range (20-200). Ignored.')
            limit = 20
    except:
        limit = 20
    cli = TwiCLI(config['user'], config['password'], interval, limit, colorer)
    cli.run()

# end of clitwi.py
